序裡說 WebAssembly 出生在瀏覽器。翻成白話:你打開一個網頁,瀏覽器抓下來執行的東西,除了 HTML、CSS、JavaScript 三件套,還有第四種,副檔名 .wasm。它不是另一種比較快的 JavaScript,是你用 Rust、C、Go 這些語言寫好、編譯出來的二進位檔,瀏覽器一樣用 fetch 抓下來、一樣叫得動。
所以瀏覽器時代這幾天就做一件事:做一個能放進瀏覽器跑的東西,歷史跟安全之後再沾著講。第一步當然是先有一顆 .wasm,今天就只做這一步:用 Rust 寫一支函數,編成它,打開來看一眼。不用會 Rust,四行而已,每個字都會講。
需要兩樣東西:Rust 工具鏈(rustc 1.95.0、cargo 1.95.0,沒裝的話去 rustup 官網照著裝)和 wasm-tools 1.250.0(Bytecode Alliance 出的瑞士刀,今天用它把二進位攤成文字看)。
rustup target add wasm32-unknown-unknown
cargo install wasm-tools
第一行值得多看一眼。wasm32-unknown-unknown 是一個 target triple,跟 x86_64-apple-darwin 那種一樣,三段分別是架構、廠商、作業系統:架構是 wasm32,廠商不指定,作業系統也不指定。這裡藏了兩件事。第一,WebAssembly 是一個編譯目標,跟 x86、ARM 同一類,不是一個語言:你不會「用 WebAssembly 寫程式」,你用 Rust 寫,請 rustc 吐成它。第二,兩個 unknown 並不是工具鏈懶得填,而是它真的不假設誰來跑它;誰載入它、給它什麼,是「宿主」的事,瀏覽器只是第一個宿主,不是唯一的。
開一個 library crate:
cargo new --lib add
cd add
把 Cargo.toml 改成這樣:
[package]
name = "add"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["cdylib"]
多出來的只有 crate-type = ["cdylib"]:告訴 cargo 這個 crate 是要給「別人」載入的動態庫,不是給其他 Rust 程式 use 的。在 wasm 這個 target 上,「動態庫」吐出來就是一顆 .wasm。
然後 src/lib.rs 整個換成:
// 一支函數,兩個 i32 進、一個 i32 出。
// `#[unsafe(no_mangle)]` 讓函式名原封不動留在 export 表裡,不然 JS 找不到 `add`。
#[unsafe(no_mangle)]
pub extern "C" fn add(x: i32, y: i32) -> i32 {
x + y
}
四行,逐字講:
pub:對外可見。extern "C":用最樸素的呼叫慣例,別帶 Rust 自己的東西。#[unsafe(no_mangle)]:名字不要改。Rust 平常會把函式名編成一長串帶雜湊的東西,改了之後 JS 那邊就找不到 add 這個名字了。edition 2024 要寫成 #[unsafe(no_mangle)],舊文章裡的 #[no_mangle] 是同一件事。i32:wasm 的整數型別之一,兩邊對得起來。編:
cargo build --release --target wasm32-unknown-unknown
預期輸出:
Compiling add v0.1.0 (/…/add)
Finished `release` profile [optimized] target(s) in 0.59s
然後:
ls -l target/wasm32-unknown-unknown/release/add.wasm
-rwxr-xr-x 1 admin staff 392 Sep 16 16:50 target/wasm32-unknown-unknown/release/add.wasm
392 bytes。WebAssembly 講到底就是這個:一個檔案 而已。不是一個平台、不是一個 SDK、不是一坨要安裝的環境,是一個你可以 cp、可以 ls、可以丟到任何地方的「檔案」,現在它在你的硬碟上。
二進位看不懂,但它有一個一比一等價的文字格式,攤開來:
wasm-tools print target/wasm32-unknown-unknown/release/add.wasm
(module $add.wasm
(type (;0;) (func (param i32 i32) (result i32)))
(table (;0;) 1 1 funcref)
(memory (;0;) 16)
(global $__stack_pointer (;0;) (mut i32) i32.const 1048576)
(global (;1;) i32 i32.const 1048576)
(global (;2;) i32 i32.const 1048576)
(export "memory" (memory 0))
(export "add" (func $add))
(export "__data_end" (global 1))
(export "__heap_base" (global 2))
(func $add (;0;) (type 0) (param i32 i32) (result i32)
local.get 1
local.get 0
i32.add
)
(@producers
(language "Rust" "")
(processed-by "rustc" "1.95.0 (59807616e 2026-04-14)")
)
(@custom "target_features" (after code) "\08+\0bbulk-memory+\0fbulk-memory-opt+\16call-indirect-overlong+\0amultivalue+\0fmutable-globals+\13nontrapping-fptoint+\0freference-types+\08sign-ext")
)
不用全部看懂,今天只看三個地方:
(func $add …) 那五行:這就是你寫的 x + y。三條指令:拿第二個參數、拿第一個參數、相加。順序跟你寫的相反,那是最佳化器自己決定的,加法可交換所以無所謂。export:這是它「給外面的東西」的清單。add 在裡面,這是未來 JS 要呼叫的名字;另外三個(memory、__data_end、__heap_base)是 Rust 工具鏈順手帶上的,今天用不到,以後的篇章會用到。import:整個檔案裡一行 import 都沒有。意思是這顆什麼都不跟宿主要,宿主什麼都不用給它。先記著這件事,明天 JS 那邊會對應到一個空的括號。中間那些 table、memory、global 是行李,最後兩塊 @producers 跟 @custom 是製造商標籤(誰編的、開了哪些功能),跟執行無關,明天結尾會回頭算它們佔了多少。
順手驗一下:
wasm-tools validate target/wasm32-unknown-unknown/release/add.wasm
沒有輸出就是合法。這個文字格式叫 WAT,它跟二進位互轉沒有損失,這是設計者刻意保留的,為什麼要刻意,以後講歷史的篇章再說。
今天就到這裡。你手上有一顆 392 B 的 add.wasm,裡面有一支你寫的函數,export 表上寫著它的名字。它到現在還沒有被任何人叫過,明天把它放進網頁,讓瀏覽器來叫。
一句帶走:WebAssembly 不是新語言,是編譯目標:你用 Rust 寫,請 rustc 吐成它;吐出來的就是一個檔案,你寫的函數在裡面,名字寫在 export 表上。